[RNE Rewrite] feat(fetcher): keep iOS downloads running while the app is backgrounded - #1369
[RNE Rewrite] feat(fetcher): keep iOS downloads running while the app is backgrounded#1369msluszniak wants to merge 2 commits into
Conversation
Benchmarking the fetcher for #1366 did not reproduce a throughput regression, but it did find a fixed cost in front of every transfer. `download()` measured each URL with a HEAD and awaited ALL of them before starting ANY transfer. So a download paid a round trip before its first byte, and against Hugging Face two, since a model URL answers with a 302 to a CDN. Worse, the wait was collective: one slow HEAD held up every file in the config. That is not theoretical, it was hit while testing against huggingface.co over a weak link, where a hung HEAD left a multi-file download sitting at 0% for five minutes with nothing on the wire. The length is only needed AFTER a transfer, to check it arrived whole, and for progress weighting, which already corrects itself from the transfer's own reported length. So the lookup is now started and not awaited: transfers begin immediately and the HEAD resolves alongside them. `expectedBytes` accordingly became a value or a promise for one, awaited at the point of use. The one place that still needs the length up front is iOS deciding whether an already-staged `.partial` is complete, and that is now guarded on the file existing at all, which in the ordinary case it does not. Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the `download()` call to the first byte: before 2.4s (server logs HEAD, then GET) after 0.2s (server logs GET, then HEAD alongside it) What the benchmark did NOT find, recorded so it isn't re-litigated: * Android is not leaving throughput on the table. Over the same link, the system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s ceiling measured with `curl` on the device itself, so it runs at ~94% of what the device can do. * On iOS both the old in-process path and the background session from #1369 ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are indistinguishable here. A throughput regression that only shows on a fast link is NOT ruled out; it would need re-measuring somewhere with real bandwidth. Part of #1366.
5a9db33 to
5bee415
Compare
Benchmarking the fetcher for #1366 did not reproduce a throughput regression, but it did find a fixed cost in front of every transfer. `download()` measured each URL with a HEAD and awaited ALL of them before starting ANY transfer. So a download paid a round trip before its first byte, and against Hugging Face two, since a model URL answers with a 302 to a CDN. Worse, the wait was collective: one slow HEAD held up every file in the config. That is not theoretical, it was hit while testing against huggingface.co over a weak link, where a hung HEAD left a multi-file download sitting at 0% for five minutes with nothing on the wire. The length is only needed AFTER a transfer, to check it arrived whole, and for progress weighting, which already corrects itself from the transfer's own reported length. So the lookup is now started and not awaited: transfers begin immediately and the HEAD resolves alongside them. `expectedBytes` accordingly became a value or a promise for one, awaited at the point of use. The one place that still needs the length up front is iOS deciding whether an already-staged `.partial` is complete, and that is now guarded on the file existing at all, which in the ordinary case it does not. Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the `download()` call to the first byte: before 2.4s (server logs HEAD, then GET) after 0.2s (server logs GET, then HEAD alongside it) What the benchmark did NOT find, recorded so it isn't re-litigated: * Android is not leaving throughput on the table. Over the same link, the system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s ceiling measured with `curl` on the device itself, so it runs at ~94% of what the device can do. * On iOS both the old in-process path and the background session from #1369 ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are indistinguishable here. A throughput regression that only shows on a fast link is NOT ruled out; it would need re-measuring somewhere with real bandwidth. Part of #1366.
aec5ec0 to
b2f40a2
Compare
Benchmarking the fetcher for #1366 did not reproduce a throughput regression, but it did find a fixed cost in front of every transfer. `download()` measured each URL with a HEAD and awaited ALL of them before starting ANY transfer. So a download paid a round trip before its first byte, and against Hugging Face two, since a model URL answers with a 302 to a CDN. Worse, the wait was collective: one slow HEAD held up every file in the config. That is not theoretical, it was hit while testing against huggingface.co over a weak link, where a hung HEAD left a multi-file download sitting at 0% for five minutes with nothing on the wire. The length is only needed AFTER a transfer, to check it arrived whole, and for progress weighting, which already corrects itself from the transfer's own reported length. So the lookup is now started and not awaited: transfers begin immediately and the HEAD resolves alongside them. `expectedBytes` accordingly became a value or a promise for one, awaited at the point of use. The one place that still needs the length up front is iOS deciding whether an already-staged `.partial` is complete, and that is now guarded on the file existing at all, which in the ordinary case it does not. Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the `download()` call to the first byte: before 2.4s (server logs HEAD, then GET) after 0.2s (server logs GET, then HEAD alongside it) What the benchmark did NOT find, recorded so it isn't re-litigated: * Android is not leaving throughput on the table. Over the same link, the system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s ceiling measured with `curl` on the device itself, so it runs at ~94% of what the device can do. * On iOS both the old in-process path and the background session from #1369 ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are indistinguishable here. A throughput regression that only shows on a fast link is NOT ruled out; it would need re-measuring somewhere with real bandwidth. Part of #1366.
There was a problem hiding this comment.
Let's not add native code to patch a missing feature in fetching library. The fetcher inside our lib is more like a helper addition, not a core functionality and there are already react-native libs that only do background fetching, so let's keep our helper simple. If a user needs more than that they can just use one of these libs for fetching.
Benchmarking the fetcher for #1366 did not reproduce a throughput regression, but it did find a fixed cost in front of every transfer. `download()` measured each URL with a HEAD and awaited ALL of them before starting ANY transfer. So a download paid a round trip before its first byte, and against Hugging Face two, since a model URL answers with a 302 to a CDN. Worse, the wait was collective: one slow HEAD held up every file in the config. That is not theoretical, it was hit while testing against huggingface.co over a weak link, where a hung HEAD left a multi-file download sitting at 0% for five minutes with nothing on the wire. The length is only needed AFTER a transfer, to check it arrived whole, and for progress weighting, which already corrects itself from the transfer's own reported length. So the lookup is now started and not awaited: transfers begin immediately and the HEAD resolves alongside them. `expectedBytes` accordingly became a value or a promise for one, awaited at the point of use. The one place that still needs the length up front is iOS deciding whether an already-staged `.partial` is complete, and that is now guarded on the file existing at all, which in the ordinary case it does not. Measured on an iPhone 16 against a body whose HEAD takes 2s, time from the `download()` call to the first byte: before 2.4s (server logs HEAD, then GET) after 0.2s (server logs GET, then HEAD alongside it) What the benchmark did NOT find, recorded so it isn't re-litigated: * Android is not leaving throughput on the table. Over the same link, the system DownloadManager moved 2.5 GiB at 275.4 MB/s against a 294.2 MB/s ceiling measured with `curl` on the device itself, so it runs at ~94% of what the device can do. * On iOS both the old in-process path and the background session from #1369 ran at 1.8-2.2 MB/s, which was this network's ceiling, so the two are indistinguishable here. A throughput regression that only shows on a fast link is NOT ruled out; it would need re-measuring somewhere with real bandwidth. Part of #1366.
b2f40a2 to
a214fcd
Compare
You're right, I'll change it to make it easy to plug an optional dependency with background download here. |
Android downloads run on the system DownloadManager, so they continue while the app is backgrounded or killed. iOS streamed in-process through blob-util, which does not. Measured on device against a 314 MB file: suspending the app tore the connection down ONE SECOND later, 43 MB in, and the server logged the drop. For a 1-3 GB model that is the difference between a download finishing and never finishing, and it is one of the ways a partial file appeared in the first place. iOS only offers background transfers through a background NSURLSession, so this adds one, owned by the package rather than borrowed from blob-util. The awkward part is that a background session only does DOWNLOAD tasks, which stage into their own private file and hand it over whole at the end. There is no partially written file to append to, so the Range-based `.partial` resume from #1365 cannot be kept alongside it. Resume instead goes through NSURLSession's own resume data, which is persisted next to the destination, so it survives the app being killed and not merely suspended. Cancelling still keeps the bytes: `cancelByProducingResumeData:` rather than a plain `cancel`. Details that were not optional: * The session identifier is fixed rather than per-request, because that is what lets iOS hand a finished transfer back to a relaunched app. It also means the session outlives the JS: `startDownload` therefore adopts a task already running for the same destination instead of starting a second one, and `resetDownload` (which backs `forceDownload`) cancels it, or "download it again" would attach to the attempt it is meant to replace. * `discretionary` is set to NO. Background sessions otherwise let iOS defer a transfer on power or network grounds, which is wrong for a download the user just asked for. * `cancelByProducingResumeData:` returns its data asynchronously. Without waiting for it, a download restarted straight after an abort read the resume file before it was written and started from zero; observed exactly that, then fixed it by having a start wait on an in-flight cancel for the same file. * A background download task never fires `didReceiveResponse`, so there is no mid-transfer status any more. The completeness check from #1365 is unaffected: it compares the bytes on disk against the expected length. Verified on an iPhone 16, against a server logging what it actually sent: * Suspended at 12.8%, the transfer kept going for 129 s and 311 of 314 MB with no drop, then delivered completion and resolved. The control run, on the old path, dropped one second after suspension. * Aborted at p=0.0793 and downloaded again: resumed at p=0.0799, and the server saw `Range: bytes=25134704-` (exactly 0.0799 of the file) rather than a second full request. Part of #1366. Known limitation: a transfer that finishes while the app is KILLED needs the host app to forward `application:handleEventsForBackgroundURLSession:`. Without it, surviving suspension still works. Left as a follow-up.
Replaces the background NSURLSession this branch added natively with an optional
dependency on `@kesha-antonov/react-native-background-downloader`. The problem is
unchanged: an in-process transfer on iOS dies with the app (measured on device,
suspension tore the connection down ONE SECOND later, 43 MB into a 314 MB file),
and only a background session keeps going. What changes is who owns that session.
The fetcher is a helper on top of the models API, not the part of this library
that should be growing native code to patch a gap in a fetching library, and
there are React Native packages that do nothing but background transfers. So RNE
uses one when the app has it installed, and keeps the in-process path when it
doesn't. The package is an optional peer dependency, so apps that don't want it
pull in nothing. Native code added earlier on this branch is removed:
RnExecutorchDownloader.{h,mm}, the three TurboModule methods, the progress
event, and the Android stubs that only existed because the spec was shared.
How the optional part works:
* `loadBackgroundDownloader()` requires the package inside a try/catch, which is
Metro's own escape hatch for optional dependencies
(`resolver.allowOptionalDependencies`, on in both React Native's and Expo's
Metro config): an uninstalled module is left unresolved rather than failing the
bundle, and the throw lands in the catch. Verified by bundling apps/nlp without
the package installed: the dependency is emitted as `null` and the bundle
builds.
* Having the JS is not the same as having the native module, so the probe also
checks that `RNBackgroundDownloader` is linked. Expo Go and a missing
`pod install` fall back rather than throwing on first use.
* The API is typed structurally in `backgroundDownloader.ts`, so nothing needs
the package present to typecheck, and a version that does not match that shape
is treated as absent.
The background path keeps the semantics the native version had, for the same
reason: iOS only offers background transfers as DOWNLOAD tasks, which stage into
their own file and hand it over whole at the end, so there is no partially
written file to append to and resume goes through NSURLSession resume data
rather than an HTTP Range request. An abort therefore PAUSES the task, which
keeps the fetched bytes where a stop would discard them, and waits for the resume
data to be written; the next download adopts that task rather than starting a
second one. `forceDownload` clears that state too, or "download it again" would
quietly continue the attempt it is meant to replace.
Two things also come for free with the library that the native version did not
have: its config plugin adds the AppDelegate
`handleEventsForBackgroundURLSession:` forwarding, which is what a transfer
finishing while the app is KILLED needs, and its resume data is persisted across
launches.
The completeness check is unchanged: the bytes on disk are compared against the
expected length before the file is promoted into the cache.
Part of #1366.
7aa4e53 to
6244556
Compare
Description
This PR adds background download functionality for iOS. Previously, only Android had out-of-the-box download functionality.
Introduces a breaking change?
Type of change
Tested on
Testing instructions
Point
download()at a few hundred MB served from a machine that logs what it actually sends, so the transfer can be observed independently of the app. Then:mainthe connection dies about a second after the app is suspended.signalpartway, then calldownload()for the same URL again. The second attempt should report progress starting near the abort point, and the server should see aRange: bytes=N-request rather than a second full one.download(..., { forceDownload: true }). The server should see a request with noRange.Android is unchanged; the three new spec methods reject there as iOS-only.
Screenshots
Measured on an iPhone 16 (iOS 26.5), server-side log:
Related issues
Part of #1366. Stacked on #1368, which is stacked on #1365.
Checklist
Additional notes
A transfer that finishes while the app is killed needs the host app to forward
application:handleEventsForBackgroundURLSession:completionHandler:. Surviving suspension works without it. I left that as a follow-up rather than adding an AppDelegate integration here, but say the word if it belongs in this PR.The download speed part of #1366 is still to come, in its own PR.